fix(orchestrator): give the dispatch lease back when a release is dead-lettered - #391
Conversation
…d-lettered Production 0.1.79 — which already contains #379 — dispatched nothing for three days. Four issues, one of them a canary filed purely to test dispatch, were all simultaneously blocked on: [factory] durable dispatch is leased by another publisher; waiting for lease release {"issue":"1540","leaseRemainingMs":189205,"retryMs":1000} while the holder logged, continuously: RelayError: Agent "ar-1540-impl-relay" has no live host node code transport_error, statusCode 503, rawCode agent_host_unavailable #379's bound is not the thing that failed. A 503 `agent_host_unavailable` is not `isAgentAlreadyGoneOnRelease`, so it lands in `failed[]` exactly like any other release failure, `#chargeReleaseAttempt` runs on every re-arm, and the dead-letter fires on schedule. What #379 did not do is give the LEASE back. `#dispatchLifecycleEpochs` is not merely a cache: `#renewDispatchLifecycles` walks it every 60 s and re-stamps a full 5-minute lease onto every key it finds, unconditionally. `#releaseDeadLetteredSlot` handed back the batch slot and left the key in that map, so a work unit this process had permanently sworn off driving kept a renewed lease for the life of the process. The `releasing` row is retained on purpose so a successor can re-drive the cleanup with a fresh budget — but a successor first has to CLAIM the row, and a lease renewed forever by a process that will never finish is a claim nobody can win. `leaseRemainingMs` of ~189 s against the 300 s TTL is that renewal, observed 111 s in. That is strictly worse than the spin #379 replaced: the spin was loud and process-local, whereas a retained lease on a non-terminal row is silent and blocks every other publisher, a restart of this one included. The fix relinquishes durable ownership wherever the budget declines a re-arm. The epoch is dropped before the durable release so a renewal tick already in flight cannot re-stamp the lease afterwards; if the durable release itself fails, dropping the epoch alone still ends the livelock, because the lease then expires within its TTL instead of never. Relinquishing at the dead-letter alone is not enough. `#driveDispatchLifecycle` re-claims the lease at the top of every drive, before it has read the phase, so anything that drives an already-dead-lettered key — the held-agent-deadline sweep, a registry restore, a takeover — puts the epoch straight back into the renewal map. `#chargeReleaseAttempt` therefore relinquishes on its abandoned early return too, not only on the transition. Deliberately NOT done: reclassifying `agent_host_unavailable` as terminal. `isAgentAlreadyGoneOnRelease` returning true means the release SUCCEEDED and the agent is gone; a 503 does not establish that, and treating it as success would checkpoint `releasedAtMs` and let worktree cleanup run against a worker that may still be alive behind a briefly unreachable host. The distinction between "host briefly down" and "host permanently gone" is not visible in a single response — it is a distinction in time, and the attempt budget is already the thing that measures it. The budget IS the terminal classifier; it just was not wired to release ownership. Tests reproduce the production shape: the verbatim RelayError 503 `agent_host_unavailable` on the remote/durable lifecycle, asserted through the invariant (a successor can claim the key) rather than through a counter, so the test states the property instead of the implementation. Both fail on origin/main by timing out on a lease that never becomes free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… renewal test Addresses both cubic-dev-ai threads on #391. P1 — the fix had the hole it was fixing. The handback ran after `#writeInFlightRegistry()`, on the happy path only, so a rejecting registry write skipped it and left the abandoned key renewing its lease forever behind nothing louder than a `warn`. That is the same shape as the defect under repair (#379 freed the batch slot but not the lease, on the failure path), reproduced one level up in its own fix. Cleanup that only runs when the rest of cleanup succeeded is not cleanup. The handback now sits in a `finally` spanning every await in `#releaseDeadLetteredSlot`, so neither `#batch()` nor `#writeInFlightRegistry()` can strand the key. It is safe there because `#relinquishDispatchLifecycleLease` handles its own errors and cannot throw, so it can never mask the failure that brought us in. `#chargeReleaseAttempt` also arms the cleanup drive BEFORE the `logger.error` call, since a caller-supplied logger is the one remaining thing between "this unit is abandoned" and "its lease is handed back" that could throw. P2 — the renewal test was vacuous, and the reviewer was exactly right. It claimed the key for a successor and asserted `lease.owner` after a fixed 200 ms sleep. `renewDispatchLifecycle` is owner+epoch fenced, so that assertion holds whether or not the epoch was dropped, and 200 ms never reaches the 60 s `DISPATCH_LIFECYCLE_RENEW_MS`, so the renewer never ran at all. The red previously reported for it was the OTHER failure mode — the 10 s `leaseIsFree` deadline, the same one the first test already covers — not the property the test named. The renewer is now driven for real through a test-only `dispatchLifecycleRenewMs` port (same precedent as `dispatchLifecycleRetryMs`; only the interval moves, the TTL stamped is the production one). The assertion samples across many renewal intervals, because a single sample cannot tell a lease that is gone from one about to come back, and additionally asserts the abandoner never even ATTEMPTED a renewal on the key. This matters because `renewDispatchLifecycle` fences on owner and epoch but NOT on expiry: a relinquished lease keeps its owner and epoch, so its own former owner can fully resurrect it. Relinquishing the durable lease while leaving the epoch cached therefore buys nothing. Each test is now pinned by ablation rather than by assertion: ablation test1 test2 test3 pre-fix origin/main RED RED RED fix minus the epoch drop grn RED grn fix minus the `finally` grn grn RED And, run in the same file against the same bug (epoch retained), the OLD test PASSES while the new one FAILS — vacuity demonstrated rather than asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
2 issues found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/orchestrator/factory.ts">
<violation number="1" location="src/orchestrator/factory.ts:7871">
P2: When another lifecycle renewal is still awaiting the state store, this handback can be followed by the renewer processing a stale snapshot entry and restoring the lease for another five minutes. Recheck the current epoch before each renewal, and make renewal reject already-expired leases so a relinquished key remains immediately claimable.</violation>
</file>
<file name="src/orchestrator/factory.test.ts">
<violation number="1" location="src/orchestrator/factory.test.ts:32102">
P3: The comment justifying the `dispatchLifecycleLeasesLost` assertion is wrong, and the assertion adds no detection. It claims a retained epoch would drive `renewDispatchLifecycle`, be refused, and count a lost lease — but `renewDispatchLifecycle` fences on owner+epoch only, not expiry, and your own earlier paragraph says a relinquished lease with owner+epoch retained is "fully resurrectable by its own former owner". So in the bug shape (durable release ran, epoch retained) the renew succeeds and no lost lease is counted; the counter stays undefined either way. The real protection is the `leaseIsFree` sampling, which is what catches the bug. Either drop the counter assertion and its rationale, or fix the comment so it does not claim the counter detects epoch retention.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Addresses both cubic-dev-ai threads from the second review of #391. P2 — the handback was still losable to a race, and my own comment claimed otherwise. `#renewDispatchLifecycles` iterates a SNAPSHOT of the owned-epoch map. Dropping the epoch before the durable release only stops a renewal tick that STARTS afterwards; a tick already in flight still carries the key, and `releaseDispatchLifecycleLease` relinquishes by dropping `leaseUntilMs` while leaving `owner` and `epoch` exactly in place. Those are precisely the credentials an owner+epoch-only renewal check accepts, so the in-flight tick restored the lease for a full term and the livelock resumed. `renewDispatchLifecycle` now fences on expiry as well, in both stores: an expired or relinquished lease must be re-CLAIMED, which bumps the epoch and fences out the previous holder, never silently extended back to life. `saveDispatchLifecycle` and `promoteDispatchLifecycle` already fenced this way, so this closes an inconsistency in the StateStore contract rather than adding a new rule — renewal was the one operation that did not check. That fence is what makes the handback safe however the two race. The epoch drop and a new live re-read of the epoch map inside the renewal loop narrow the window ahead of it, but neither closes it alone, and the comment that claimed the ordering was sufficient has been corrected rather than left to mislead the next reader. P3 — the `dispatchLifecycleLeasesLost` assertion was justified by reasoning that contradicted the paragraph above it, and detected nothing. It claimed a retained epoch would drive a renewal, be refused, and count a lost lease; but renewal fenced on owner and epoch, both of which still match in the bug shape, so the renewal SUCCEEDED and no lease was ever counted lost. The counter stayed undefined either way. Removed, with a note saying so, rather than kept behind a corrected comment: the `leaseIsFree` sampling is the whole of the detection and the ablation table is what demonstrates it. New store-level test asserts the fence against BOTH implementations from one script, because a fence that holds in only one of them is not a fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 1a0d070b-c811-486f-9a86-9e9743becdb7
|
@coderabbitai review Requested for exact head |
DO NOT MERGE without the principal's gate. Live P0 investigation; opened for review.
What production was doing
0.1.79 (which already contains #379) dispatched nothing for three days. Four issues — including a canary filed purely to test dispatch — were all simultaneously blocked on the same shape:
while the release for 1540's agents failed, continuously, all day:
Does the 503 path charge the release-attempt counter? Yes.
The working hypothesis going in was that a
retryable: true503 takes a different branch and re-arms without charging, making #379's bound a no-op on the case it was written for. That is not what happens, and the hypothesis is refuted.#releaseAndTerminateAgentshas exactly one classifier on the release error,isAgentAlreadyGoneOnRelease, andrelease-error.tsdocuments — andrelease-error.test.ts:48already asserts — that a 503agent_host_unavailableis not "already gone". So it falls to theelse, lands infailed[], and produces the verbatim production log line.#finishDurableReleasethen seesfailed.length > 0and calls#scheduleReleaseRetry, which on the durable lifecycle calls#scheduleDispatchLifecycleRetry(..., { releaseAttempt: true }), which calls#chargeReleaseAttempt. A 503 is charged exactly like the plainErrorthe #379 suite throws.This is confirmed empirically, not by reading: the first two assertions of the new tests — the
dispatchLifecycleReleaseAbandonedcounter reaching 1, and therelease retries exhausted; abandoning cleanup for this work uniterror being logged — pass on unmodifiedorigin/mainwhen driven by the verbatim productionRelayError.A correction to the brief while we're here: #379's suite does already cover the remote/durable resolve-then-re-arm cycle, via
DurableCompletionReleaseFailingFleetClient extends RemoteLifecycleFleetClient, with a test named "exhausts the budget on the durable lifecycle, where the failed release resolves instead of throwing". The gap was not the placement locality and not the resolve-vs-throw shape.Why the lease never expires
#dispatchLifecycleEpochsis not merely a cache.#renewDispatchLifecycleswalks it everyDISPATCH_LIFECYCLE_RENEW_MS(60 s) and re-stamps a fullDISPATCH_LIFECYCLE_LEASE_MS(300 s) onto every key it finds, unconditionally.#releaseDeadLetteredSlothanded back the batch slot and left the key in that map. So a work unit this process had permanently sworn off driving kept a lease that was renewed for the life of the process.leaseRemainingMsof ~189 s against the 300 s TTL is precisely that renewal, observed 111 s in — the lease is not stuck, it is being actively re-stamped.The
releasingrow is retained on purpose, so a successor or a restart can re-drive the cleanup with a fresh budget. But a successor first has to claim the row, and a lease renewed forever by a process that will never finish is a claim nobody can win. That is a livelock, and it is strictly worse than the spin #379 replaced: the spin was loud and process-local, whereas a retained lease on a non-terminal row is silent and blocks every other publisher, a restart of this one included.Worth noting for the record: a retained
releasingrow does not consume durable batch capacity (dispatchPhaseOccupiesSlotexcludesreleasing), so the fleet-wide stall is not abatchSizeexhaustion. The blocking is strictly per-key, which means each of the four blocked issues has its own retained lease.The fix
Relinquish durable ownership wherever the release budget declines a re-arm.
#relinquishDispatchLifecycleLease(key, issueKey): drops the epoch first so a renewal tick already in flight cannot re-stamp after the release, then callsreleaseDispatchLifecycleLease. If that durable call fails, dropping the epoch alone still ends the livelock — nothing renews the lease any more, so it expires within its TTL instead of never.#releaseDeadLetteredSlotcalls it alongside handing back the batch slot, before admitting anything new and beforedispatchgets a chance to throw.#chargeReleaseAttemptcalls it on its abandoned early return as well, not only on the transition. This closes a re-entry hole:#driveDispatchLifecyclere-claims the lease at the top of every drive, before it has read the phase, so anything that drives an already-dead-lettered key — the held-agent-deadline sweep (which calls#finishDurableReleasedirectly atHELD_DEADLINE_OVERDUE_RETRY_MS= 1 s and consults a different abandoned set), a registry restore, a takeover — would otherwise put the epoch straight back into the renewal map and re-arm the livelock the bound just escaped.This extends #379's dead-letter rather than adding a parallel mechanism, as asked. #379's structural test (
charges the release budget from the release scheduler only, which pins#chargeReleaseAttemptto exactly two call sites) still passes unchanged.Deliberately NOT done: classifying
agent_host_unavailableas terminalThe brief asked for an argument either way. Against:
isAgentAlreadyGoneOnReleasereturningtruemeans the release succeeded — the agent is gone. A 503 does not establish that. The host may be briefly unreachable while the agent process is alive and holding a shared per-issue worktree. Classifying it as gone would checkpointreleasedAtMs, mark the invocation non-dispatchable, and let#cleanupAgentWorktreesrun against a live worker.release-error.tsalready warns about exactly this ("a 5xx from the broker itself is a real fault and must not silently succeed").Tests
Two tests reproducing the production shape on the remote/durable lifecycle, throwing the verbatim
RelayError(name: 'RelayError',code: 'transport_error',retryable: true,statusCode: 503,rawCode: 'agent_host_unavailable').They assert through the invariant — a successor publisher can claim the key — rather than through a counter, so they state the property rather than the implementation, and the poll is read-only so it cannot itself take the lease it is proving is available.
Red (final tests, unmodified
src/orchestrator/factory.ts)Both fail by exhausting their own 10 s deadline waiting for a lease that never becomes free — the failure is a property of the loop, not of a number chosen in the test. Note that everything before that wait passes on main: the dead-letter fires, the "release retries exhausted" error is logged. That is the Step 1 answer stated as a test.
Green
tsc -p tsconfig.build.json --noEmitis clean. All 46 release-related tests infactory.test.tspass.Not touched
No merge, no publish, no tag, no deploy, no change to
factory.config.json. The production lease was not cleared and nothing was restarted — that is the principal's call and is being handled separately.🤖 Generated with Claude Code
Review round 2 (cubic-dev-ai, both threads addressed in 3c02a60)
P1 — the fix had the hole it was fixing. The handback ran after
#writeInFlightRegistry(), on the happy path only, so a rejecting registry write skipped it and left the abandoned key renewing its lease forever behind nothing louder than awarn. Same shape as the defect under repair, one level up. It is now in afinallyspanning every await in#releaseDeadLetteredSlot, and the cleanup drive is armed before thelogger.errorcall — the one remaining thing between "abandoned" and "lease handed back" that could throw.P2 — the renewal test was vacuous. The reviewer was correct and the red I originally reported for that test was the 10s
leaseIsFreedeadline, not the renewal property.renewDispatchLifecycleis owner+epoch fenced, so the old assertion held either way, and 200ms never reached the 60s renewal interval. The renewer now runs for real via a test-onlydispatchLifecycleRenewMsport.The sharper point that surfaced while fixing it:
renewDispatchLifecyclefences on owner and epoch but not on expiry. A relinquished lease keeps its owner and epoch, so its own former owner can fully resurrect it. Relinquishing the durable lease while leaving the epoch cached buys nothing.Ablation matrix
Every test is now pinned by ablation rather than by assertion:
finally)origin/mainfinallyAnd, in the same file against the same bug (epoch retained), the old test passes while the new one fails — vacuity demonstrated, not asserted.
Final green:
tsc -p tsconfig.build.json --noEmitclean; 47 release-related tests infactory.test.tspass.Base
No rebase was required: this branch's merge-base is
87fcbf3, which isorigin/main's tip. #387 (b464fed) and #389 (87fcbf3) were both already in the base — 87fcbf3 is this branch's parent commit. Re-verified againstorigin/mainafter the review round; it had not moved.Explicitly DEFERRED to follow-up (not in this PR)
Two adjacent defects found during the investigation. Both pre-date this PR, neither is on the lease-retention path it fixes, and folding them in would widen a P0 fix's blast radius on a file another lane is editing with autonomous merge authority. Recording them here so they are not lost:
The release budget under-counts.
#scheduleDispatchLifecycleRetryreturns on#dispatchLifecycleRetryTimers.has(key)before#chargeReleaseAttemptruns, so any release issued while a timer is already armed is free. The 1 Hz held-agent-deadline sweep issues exactly such releases. Fixing it means moving the charge ahead of the dedupe guard, which changes charging semantics for capacity and ownership waits too — that needs its own reasoning and its own tests, not a rider on this one.The held-agent-deadline sweep ignores the dead-letter.
#sweepHeldAgentDeadlinesgates on#abandonedDispatchReasons, not#dispatchLifecycleReleaseAbandoned, and calls#finishDurableReleasedirectly for areleasingrow. It can therefore keep issuing releases against a dead-lettered unit, which plausibly explains the all-day repeating release-failure log even after the bound fired.On whether (2) means the production symptom persists after this PR: the blocking symptom does not, the log noise may.
#releaseDeadLetteredSlotcallsbatch.complete(record.issue), which removes the record frominFlight, and the sweep only iteratesinFlight— so the sweep stops touching it in this process unless the record is restored from the registry. The four-issues-blocked-on-a-lease symptom is fixed regardless, because that was caused by lease retention, which is what this PR ends. What this PR converts the failure into is bounded rounds: a successor claims the freed key, gets a fresh budget, spends 10 attempts, dead-letters, and hands the lease back again. That is #379's documented intent ("a takeover or a restart re-drives it from the persisted phase") and it no longer blocks any other key. If the agents never come back, that is a slow hot-potato rather than a permanent block — worth its own issue, and it is what (1) and (2) would tighten.Review round 3 (cubic-dev-ai, both threads addressed in 1303ceb)
P2 — the handback was still losable to a race, and my own comment claimed otherwise.
#renewDispatchLifecyclesiterates a snapshot of the owned-epoch map, so dropping the epoch before the durable release only stops a tick that starts afterwards. A tick already in flight still carries the key — andreleaseDispatchLifecycleLeaserelinquishes by droppingleaseUntilMswhile leavingownerandepochin place, which are exactly the credentials an owner+epoch-only check accepts. It restored the lease for a full term and the livelock resumed.renewDispatchLifecyclenow fences on expiry as well, in both stores. That is the load-bearing fix: it makes a relinquished lease unrenewable however the handback and an in-flight renewal interleave. A live re-read of the epoch map inside the renewal loop narrows the window ahead of it but does not close it alone, and the comment now says so rather than overclaiming.This closes a contract inconsistency rather than adding a rule.
saveDispatchLifecycleandpromoteDispatchLifecyclealready refuse whenleaseUntilMs <= nowMs, and so does the discovery sweep renewal (renewDiscoverySweepWithDetailsreturnsreason: 'expired'). Dispatch-lifecycle renewal was the only lease operation in the store that did not check expiry.P3 — the
dispatchLifecycleLeasesLostassertion was justified by reasoning that contradicted the paragraph above it, and detected nothing. In the bug shape the renewal succeeds (owner and epoch both match), so no lease is ever counted lost and the counter stays undefined either way. Removed, with a note recording why, so it does not get re-added. TheleaseIsFreesampling is the whole of the detection.Ablation matrix (updated)
finally)origin/mainfinallyVerification on the final tree:
tsc -p tsconfig.build.json --noEmitclean; 47 release-related tests infactory.test.tspass; 137 tests acrosssrc/state,src/dispatch,release-errorandrelease-statepass with the new fence.The store fence is asserted against both implementations from one script, because a fence that holds in only one of them is not a fence.